#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
'''

'''

#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations

import logging # isort:skip
log = logging.getLogger(__name__)

#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------

# Standard library imports
from typing import (
    TYPE_CHECKING,
    Any,
    Literal,
    Sequence,
    TypeAlias,
    TypedDict,
    cast,
    overload,
)

# Bokeh imports
from .. import __version__
from ..core.templates import (
    AUTOLOAD_JS,
    AUTOLOAD_TAG,
    FILE,
    MACROS,
    ROOT_DIV,
)
from ..document.document import DEFAULT_TITLE, Document
from ..model import Model
from ..resources import Resources, ResourcesLike
from .bundle import Script, bundle_for_objs_and_resources
from .elements import html_page_for_render_items, script_for_render_items
from .util import (
    FromCurdoc,
    OutputDocumentFor,
    RenderRoot,
    standalone_docs_json,
    standalone_docs_json_and_render_items,
)
from .wrappers import wrap_in_onload

if TYPE_CHECKING:
    from jinja2 import Template

    from ..core.types import ID
    from ..document.document import DocJson
    from ..themes import Theme

    ModelLike: TypeAlias = Model | Document
    ModelLikeCollection: TypeAlias = Sequence[ModelLike] | dict[str, ModelLike]

    ThemeLike: TypeAlias = None | Theme | type[FromCurdoc]

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------

__all__ = (
    'autoload_static',
    'components',
    'file_html',
    'json_item',
)

#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------

def autoload_static(model: Model | Document, resources: Resources, script_path: str) -> tuple[str, str]:
    ''' Return JavaScript code and a script tag that can be used to embed
    Bokeh Plots.

    The data for the plot is stored directly in the returned JavaScript code.

    Args:
        model (Model or Document) :

        resources (Resources) :

        script_path (str) :

    Returns:
        (js, tag) :
            JavaScript code to be saved at ``script_path`` and a ``<script>``
            tag to load it

    Raises:
        ValueError

    '''
    # TODO: maybe warn that it's not exactly useful, but technically possible
    # if resources.mode == 'inline':
    #     raise ValueError("autoload_static() requires non-inline resources")

    if isinstance(model, Model):
        models = [model]
    elif isinstance (model, Document):
        models = model.roots
    else:
        raise ValueError("autoload_static expects a single Model or Document")

    with OutputDocumentFor(models):
        (docs_json, [render_item]) = standalone_docs_json_and_render_items([model])

    bundle = bundle_for_objs_and_resources(None, resources)
    bundle.add(Script(script_for_render_items(docs_json, [render_item])))

    (_, elementid) = next(iter(render_item.roots.to_json().items()))

    js = wrap_in_onload(AUTOLOAD_JS.render(bundle=bundle, elementid=elementid))

    tag = AUTOLOAD_TAG.render(
        src_path = script_path,
        elementid = elementid,
    )

    return js, tag

@overload
def components(models: Model, wrap_script: bool = ...,
    wrap_plot_info: Literal[True] = ..., theme: ThemeLike = ...) -> tuple[str, str]: ...
@overload
def components(models: Model, wrap_script: bool = ..., wrap_plot_info: Literal[False] = ...,
    theme: ThemeLike = ...) -> tuple[str, RenderRoot]: ...

@overload
def components(models: Sequence[Model], wrap_script: bool = ...,
    wrap_plot_info: Literal[True] = ..., theme: ThemeLike = ...) -> tuple[str, Sequence[str]]: ...
@overload
def components(models: Sequence[Model], wrap_script: bool = ..., wrap_plot_info: Literal[False] = ...,
    theme: ThemeLike = ...) -> tuple[str, Sequence[RenderRoot]]: ...

@overload
def components(models: dict[str, Model], wrap_script: bool = ...,
    wrap_plot_info: Literal[True] = ..., theme: ThemeLike = ...) -> tuple[str, dict[str, str]]: ...
@overload
def components(models: dict[str, Model], wrap_script: bool = ..., wrap_plot_info: Literal[False] = ...,
    theme: ThemeLike = ...) -> tuple[str, dict[str, RenderRoot]]: ...

def components(models: Model | Sequence[Model] | dict[str, Model], wrap_script: bool = True,
               wrap_plot_info: bool = True, theme: ThemeLike = None) -> tuple[str, Any]:
    ''' Return HTML components to embed a Bokeh plot. The data for the plot is
    stored directly in the returned HTML.

    An example can be found in examples/embed/embed_multiple.py

    The returned components assume that BokehJS resources are **already loaded**.
    The HTML document or template in which they will be embedded needs to
    include scripts tags, either from a local URL or Bokeh's CDN (replacing
    ``x.y.z`` with the version of Bokeh you are using):

    .. code-block:: html

        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-x.y.z.min.js"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-x.y.z.min.js"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-x.y.z.min.js"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-x.y.z.min.js"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-x.y.z.min.js"></script>

    Only the Bokeh core library ``bokeh-x.y.z.min.js`` is always required. The
    other scripts are optional and only need to be included if you want to use
    corresponding features:

    * The ``"bokeh-widgets"`` files are only necessary if you are using any of the
      :ref:`Bokeh widgets <ug_interaction_widgets>`.
    * The ``"bokeh-tables"`` files are only necessary if you are using Bokeh's
      :ref:`data tables <ug_interaction_widgets_examples_datatable>`.
    * The ``"bokeh-api"`` files are required to use the
      :ref:`BokehJS API <ug_advanced_bokehjs>` and must be loaded *after* the
      core BokehJS library.
    * The ``"bokeh-gl"`` files are required to enable
      :ref:`WebGL support <ug_output_webgl>`.
    * the ``"bokeh-mathjax"`` files are required to enable
      :ref:`MathJax support <ug_styling_mathtext>`.

    Args:
        models (Model|list|dict|tuple) :
            A single Model, a list/tuple of Models, or a dictionary of keys
            and Models.

        wrap_script (boolean, optional) :
            If True, the returned javascript is wrapped in a script tag.
            (default: True)

        wrap_plot_info (boolean, optional) :
            If True, returns ``<div>`` strings. Otherwise, return
            :class:`~bokeh.embed.RenderRoot` objects that can be used to build
            your own divs. (default: True)

        theme (Theme, optional) :
            Applies the specified theme when creating the components. If None,
            or not specified, and the supplied models constitute the full set
            of roots of a document, applies the theme of that document to the
            components. Otherwise applies the default theme.

    Returns:
        UTF-8 encoded *(script, div[s])* or *(raw_script, plot_info[s])*

    Examples:

        With default wrapping parameter values:

        .. code-block:: python

            components(plot)
            # => (script, plot_div)

            components((plot1, plot2))
            # => (script, (plot1_div, plot2_div))

            components({"Plot 1": plot1, "Plot 2": plot2})
            # => (script, {"Plot 1": plot1_div, "Plot 2": plot2_div})

    Examples:

        With wrapping parameters set to ``False``:

        .. code-block:: python

            components(plot, wrap_script=False, wrap_plot_info=False)
            # => (javascript, plot_root)

            components((plot1, plot2), wrap_script=False, wrap_plot_info=False)
            # => (javascript, (plot1_root, plot2_root))

            components({"Plot 1": plot1, "Plot 2": plot2}, wrap_script=False, wrap_plot_info=False)
            # => (javascript, {"Plot 1": plot1_root, "Plot 2": plot2_root})

    '''
    # 1) Convert single items and dicts into list
    # XXX: was_single_object = isinstance(models, Model) #or isinstance(models, Document)
    was_single_object = False

    if isinstance(models, Model):
        was_single_object = True
        models = [models]

    models = _check_models_or_docs(models) # type: ignore # XXX: this API needs to be refined

    # now convert dict to list, saving keys in the same order
    model_keys = None
    dict_type: type[dict[Any, Any]] = dict
    if isinstance(models, dict):
        dict_type = models.__class__
        model_keys = models.keys()
        models = list(models.values())

    # 2) Append models to one document. Either pre-existing or new and render
    with OutputDocumentFor(models, apply_theme=theme):
        (docs_json, [render_item]) = standalone_docs_json_and_render_items(models)

    bundle = bundle_for_objs_and_resources(None, None)
    bundle.add(Script(script_for_render_items(docs_json, [render_item])))

    script = bundle.scripts(tag=wrap_script)

    def div_for_root(root: RenderRoot) -> str:
        return ROOT_DIV.render(root=root, macros=MACROS)

    results: list[str] | list[RenderRoot]
    if wrap_plot_info:
        results = [div_for_root(root) for root in render_item.roots]
    else:
        results = list(render_item.roots)

    # 3) convert back to the input shape
    result: Any
    if was_single_object:
        result = results[0]
    elif model_keys is not None:
        result = dict_type(zip(model_keys, results))
    else:
        result = tuple(results)

    return script, result

def file_html(models: Model | Document | Sequence[Model],
              resources: ResourcesLike | None = None,
              title: str | None = None,
              *,
              template: Template | str = FILE,
              template_variables: dict[str, Any] = {},
              theme: ThemeLike = None,
              suppress_callback_warning: bool = False,
              _always_new: bool = False) -> str:
    ''' Return an HTML document that embeds Bokeh Model or Document objects.

    The data for the plot is stored directly in the returned HTML, with
    support for customizing the JS/CSS resources independently and
    customizing the jinja2 template.

    Args:
        models (Model or Document or seq[Model]) : Bokeh object or objects to render
            typically a Model or Document

        resources (ResourcesLike) :
            A resources configuration for Bokeh JS & CSS assets.

        title (str, optional) :
            A title for the HTML document ``<title>`` tags or None. (default: None)

            If None, attempt to automatically find the Document title from the given
            plot objects.

        template (Template, optional) : HTML document template (default: FILE)
            A Jinja2 Template, see bokeh.core.templates.FILE for the required
            template parameters

        template_variables (dict, optional) : variables to be used in the Jinja2
            template. If used, the following variable names will be overwritten:
            title, bokeh_js, bokeh_css, plot_script, plot_div

        theme (Theme, optional) :
            Applies the specified theme to the created html. If ``None``, or
            not specified, and the function is passed a document or the full set
            of roots of a document, applies the theme of that document.  Otherwise
            applies the default theme.

        suppress_callback_warning (bool, optional) :
            Normally generating standalone HTML from a Bokeh Document that has
            Python callbacks will result in a warning stating that the callbacks
            cannot function. However, this warning can be suppressed by setting
            this value to True (default: False)

    Returns:
        UTF-8 encoded HTML

    '''
    models_seq: Sequence[Model] = []
    if isinstance(models, Model):
        models_seq = [models]
    elif isinstance(models, Document):
        if len(models.roots) == 0:
            raise ValueError("Document has no root Models")
        models_seq = models.roots
    else:
        models_seq = models

    resources = Resources.build(resources)

    with OutputDocumentFor(models_seq, apply_theme=theme, always_new=_always_new) as doc:
        (docs_json, render_items) = standalone_docs_json_and_render_items(models_seq, suppress_callback_warning=suppress_callback_warning)
        title = _title_from_models(models_seq, title)
        bundle = bundle_for_objs_and_resources([doc], resources)
        return html_page_for_render_items(bundle, docs_json, render_items, title=title,
                                          template=template, template_variables=template_variables)

class StandaloneEmbedJson(TypedDict):
    target_id: ID | None
    root_id: ID
    doc: DocJson
    version: str

def json_item(model: Model, target: ID | None = None, theme: ThemeLike = None) -> StandaloneEmbedJson:
    ''' Return a JSON block that can be used to embed standalone Bokeh content.

    Args:
        model (Model) :
            The Bokeh object to embed

        target (string, optional)
            A div id to embed the model into. If None, the target id must
            be supplied in the JavaScript call.

        theme (Theme, optional) :
            Applies the specified theme to the created html. If ``None``, or
            not specified, and the function is passed a document or the full set
            of roots of a document, applies the theme of that document.  Otherwise
            applies the default theme.

    Returns:
        JSON-like

    This function returns a JSON block that can be consumed by the BokehJS
    function ``Bokeh.embed.embed_item``. As an example, a Flask endpoint for
    ``/plot`` might return the following content to embed a Bokeh plot into
    a div with id *"myplot"*:

    .. code-block:: python

        @app.route('/plot')
        def plot():
            p = make_plot('petal_width', 'petal_length')
            return json.dumps(json_item(p, "myplot"))

    Then a web page can retrieve this JSON and embed the plot by calling
    ``Bokeh.embed.embed_item``:

    .. code-block:: html

        <script>
        fetch('/plot')
            .then(function(response) { return response.json(); })
            .then(function(item) { Bokeh.embed.embed_item(item); })
        </script>

    Alternatively, if is more convenient to supply the target div id directly
    in the page source, that is also possible. If `target_id` is omitted in the
    call to this function:

    .. code-block:: python

        return json.dumps(json_item(p))

    Then the value passed to ``embed_item`` is used:

    .. code-block:: javascript

        Bokeh.embed.embed_item(item, "myplot");

    '''
    with OutputDocumentFor([model], apply_theme=theme) as doc:
        doc.title = ""
        [doc_json] = standalone_docs_json([model]).values()

    root_id = doc_json["roots"][0]["id"]

    return StandaloneEmbedJson(
        target_id = target,
        root_id   = root_id,
        doc       = doc_json,
        version   = __version__,
    )

#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------

def _check_models_or_docs(models: ModelLike | ModelLikeCollection) -> ModelLikeCollection:
    '''

    '''
    input_type_valid = False

    # Check for single item
    if isinstance(models, (Model, Document)):
        models = [models]

    # Check for sequence
    if isinstance(models, Sequence) and all(isinstance(x, (Model, Document)) for x in models):
        input_type_valid = True

    if isinstance(models, dict) and \
        all(isinstance(x, str) for x in models.keys()) and \
        all(isinstance(x, (Model, Document)) for x in models.values()):
        input_type_valid = True

    if not input_type_valid:
        raise ValueError(
            'Input must be a Model, a Document, a Sequence of Models and Document, or a dictionary from string to Model and Document',
        )

    return models

def _title_from_models(models: Sequence[Model | Document], title: str | None) -> str:
    # use override title
    if title is not None:
        return title

    # use title from any listed document
    for p in models:
        if isinstance(p, Document):
            return p.title

    # use title from any model's document
    for p in cast(Sequence[Model], models):
        if p.document is not None:
            return p.document.title

    # use default title
    return DEFAULT_TITLE

#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------
